'use strict'

/* eslint-disable no-unused-expressions */

const chai = require('chai')
const overrideEnv = require('process-utils/override-env')
const overrideArgv = require('process-utils/override-argv')
const runServerless = require('../../../utils/run-serverless')
const fixtures = require('../../../fixtures/programmatic')
const Serverless = require('../../../../lib/serverless')
const CLI = require('../../../../lib/classes/cli')
const resolveInput = require('../../../../lib/cli/resolve-input')
const Create = require('../../../../lib/plugins/create/create')
const ServerlessError = require('../../../../lib/serverless-error')
const getRequire = require('../../../../lib/utils/get-require')

const path = require('path')
const fsp = require('fs').promises
const fse = require('fs-extra')
const mockRequire = require('mock-require')
const sinon = require('sinon')
const proxyquire = require('proxyquire')
const { installPlugin } = require('../../../utils/plugins')
const { getTmpDirPath } = require('../../../utils/fs')

chai.use(require('chai-as-promised'))
chai.use(require('sinon-chai'))

const expect = chai.expect

class PromisePluginMock {
  constructor() {
    this.commands = {
      deploy: {
        usage: 'Deploy to the default infrastructure',
        lifecycleEvents: ['resources', 'functions'],
        options: {
          resource: {
            usage: 'The resource you want to deploy (e.g. --resource db)',
          },
          function: {
            usage: 'The function you want to deploy (e.g. --function create)',
          },
        },
        commands: {
          onpremises: {
            usage: 'Deploy to your On-Premises infrastructure',
            lifecycleEvents: ['resources', 'functions'],
            options: {
              resource: {
                usage: 'The resource you want to deploy (e.g. --resource db)',
              },
              function: {
                usage:
                  'The function you want to deploy (e.g. --function create)',
              },
            },
          },
          other: {
            usage: 'Deploy to other infrastructure',
            lifecycleEvents: ['resources', 'functions'],
          },
        },
      },
    }

    this.hooks = {
      'deploy:functions': async () => this.functions(),
      'before:deploy:onpremises:functions': async () => this.resources(),
    }

    // used to test if the function was executed correctly
    this.deployedFunctions = 0
    this.deployedResources = 0
  }

  async functions() {
    this.deployedFunctions += 1
  }

  async resources() {
    this.deployedResources += 1
  }
}

class SynchronousPluginMock {
  constructor() {
    this.commands = {
      deploy: {
        usage: 'Deploy to the default infrastructure',
        lifecycleEvents: ['resources', 'functions'],
        options: {
          resource: {
            usage: 'The resource you want to deploy (e.g. --resource db)',
            type: 'string',
          },
          function: {
            usage: 'The function you want to deploy (e.g. --function create)',
            type: 'string',
          },
        },
        commands: {
          onpremises: {
            usage: 'Deploy to your On-Premises infrastructure',
            lifecycleEvents: ['resources', 'functions'],
            options: {
              resource: {
                usage: 'The resource you want to deploy (e.g. --resource db)',
                type: 'string',
              },
              function: {
                usage:
                  'The function you want to deploy (e.g. --function create)',
                type: 'string',
              },
            },
          },
        },
      },
    }

    this.hooks = {
      'deploy:functions': async () => this.functions(),
      'before:deploy:onpremises:functions': async () => this.resources(),
    }

    // used to test if the function was executed correctly
    this.deployedFunctions = 0
    this.deployedResources = 0
  }

  functions() {
    this.deployedFunctions += 1
  }

  resources() {
    this.deployedResources += 1
  }
}

describe('PluginManager', () => {
  let pluginManager
  let serverless

  class ServicePluginMock1 {}

  class ServicePluginMock2 {}

  class EnterprisePluginMock {}

  const brokenPluginError = new Error('Broken plugin')
  class BrokenPluginMock {
    constructor() {
      throw brokenPluginError
    }
  }

  class Provider1PluginMock {
    constructor() {
      this.provider = 'provider1'

      this.commands = {
        deploy: {
          lifecycleEvents: ['resources'],
        },
      }

      this.hooks = {
        'deploy:functions': async () => this.functions(),
      }

      // used to test if the function was executed correctly
      this.deployedFunctions = 0
    }

    functions() {
      this.deployedFunctions += 1
    }
  }

  class Provider2PluginMock {
    constructor() {
      this.provider = 'provider2'

      this.commands = {
        deploy: {
          lifecycleEvents: ['resources'],
        },
      }

      this.hooks = {
        'deploy:functions': async () => this.functions(),
      }

      // used to test if the function was executed correctly
      this.deployedFunctions = 0
    }

    functions() {
      this.deployedFunctions += 1
    }
  }

  class AliasPluginMock {
    constructor() {
      this.commands = {
        deploy: {
          usage: 'Deploy to the default infrastructure',
          lifecycleEvents: ['resources', 'functions'],
          options: {
            resource: {
              usage: 'The resource you want to deploy (e.g. --resource db)',
              type: 'string',
            },
            function: {
              usage: 'The function you want to deploy (e.g. --function create)',
              type: 'string',
            },
          },
          commands: {
            onpremises: {
              usage: 'Deploy to your On-Premises infrastructure',
              lifecycleEvents: ['resources', 'functions'],
              aliases: ['on:premise', 'premise'],
              options: {
                resource: {
                  usage: 'The resource you want to deploy (e.g. --resource db)',
                  type: 'string',
                },
                function: {
                  usage:
                    'The function you want to deploy (e.g. --function create)',
                  type: 'string',
                },
              },
            },
          },
        },
      }

      this.hooks = {
        'deploy:functions': async () => this.functions(),
        'before:deploy:onpremises:functions': async () => this.resources(),
      }

      // used to test if the function was executed correctly
      this.deployedFunctions = 0
      this.deployedResources = 0
    }

    functions() {
      this.deployedFunctions += 1
    }

    resources() {
      this.deployedResources += 1
    }
  }

  class EntrypointPluginMock {
    constructor() {
      this.commands = {
        myep: {
          type: 'entrypoint',
          lifecycleEvents: ['initialize', 'finalize'],
          commands: {
            // EP, not public command because its parent is decalred as EP
            mysubep: {
              lifecycleEvents: ['initialize', 'finalize'],
            },
            // EP that will spawn sub lifecycles
            spawnep: {
              lifecycleEvents: ['event1', 'event2'],
            },
          },
        },
        // public command
        mycmd: {
          lifecycleEvents: ['run'],
          commands: {
            // public subcommand
            mysubcmd: {
              lifecycleEvents: ['initialize', 'finalize'],
            },
            // command that will spawn sub lifecycles
            spawncmd: {
              lifecycleEvents: ['event1', 'event2'],
            },
            spawnep: {
              type: 'entrypoint',
              lifecycleEvents: ['event1', 'event2'],
            },
          },
        },
      }

      this.hooks = {
        'myep:initialize': async () => this.initialize(),
        'myep:finalize': async () => this.finalize(),
        'myep:mysubep:initialize': async () => this.subEPInitialize(),
        'myep:mysubep:finalize': async () => this.subEPFinalize(),
        'mycmd:mysubcmd:initialize': async () => this.subInitialize(),
        'mycmd:mysubcmd:finalize': async () => this.subFinalize(),
        'mycmd:run': async () => this.run(),
        // Event1 spawns mysubcmd, then myep
        // Event2 spawns mycmd, then mysubep
        'myep:spawnep:event1': async () =>
          pluginManager
            .spawn(['mycmd', 'mysubcmd'])
            .then(() => pluginManager.spawn(['myep'])),
        'myep:spawnep:event2': async () =>
          pluginManager
            .spawn(['mycmd'])
            .then(() => pluginManager.spawn(['myep', 'mysubep'])),
        'mycmd:spawncmd:event1': async () =>
          pluginManager
            .spawn(['mycmd', 'mysubcmd'])
            .then(() => pluginManager.spawn(['myep'])),
        'mycmd:spawncmd:event2': async () =>
          pluginManager
            .spawn(['mycmd'])
            .then(() => pluginManager.spawn(['myep', 'mysubep'])),
      }

      this.callResult = ''
    }

    initialize() {
      this.callResult += '>initialize'
    }

    finalize() {
      this.callResult += '>finalize'
    }

    subEPInitialize() {
      this.callResult += '>subEPInitialize'
    }

    subEPFinalize() {
      this.callResult += '>subEPFinalize'
    }

    subInitialize() {
      this.callResult += '>subInitialize'
    }

    subFinalize() {
      this.callResult += '>subFinalize'
    }

    run() {
      this.callResult += '>run'
    }
  }

  class ContainerPluginMock {
    constructor() {
      this.commands = {
        // not a public command because its declared as a Container
        mycontainer: {
          type: 'container',
          commands: {
            // public command because its children of a container
            mysubcmd: {
              lifecycleEvents: ['event1', 'event2'],
            },
          },
        },
      }

      this.hooks = {
        'mycontainer:mysubcmd:event1': async () => this.eventOne(),
        'mycontainer:mysubcmd:event2': async () => this.eventTwo(),
      }

      this.callResult = ''
    }

    eventOne() {
      this.callResult += '>mysubcmdEvent1'
    }

    eventTwo() {
      this.callResult += '>mysubcmdEvent2'
    }
  }

  class DeprecatedLifecycleEventsPluginMock {
    constructor() {
      this.hooks = {
        'deprecated:deprecated': async () => this.deprecated(), // NOTE: we assume that this is deprecated
        'untouched:untouched': async () => this.untouched(),
      }
    }

    deprecated() {
      return
    }

    untouched() {
      return
    }
  }

  const resolveStub = (directory, pluginPath) => {
    switch (pluginPath) {
      case 'BrokenPluginMock':
      case 'ServicePluginMock1':
      case 'ServicePluginMock2':
        return pluginPath
      case './RelativePath/ServicePluginMock2':
        return `${serviceDir}/RelativePath/ServicePluginMock2`
      default:
        return getRequire(directory).resolve(pluginPath)
    }
  }

  let restoreEnv
  let serviceDir
  const PluginManager = proxyquire('../../../../lib/classes/plugin-manager', {
    '../utils/get-require': (directory) => {
      const resultRequire = require('module').createRequire(
        path.resolve(directory, 'req'),
      )
      resultRequire.resolve = (pluginPath) => resolveStub(directory, pluginPath)
      return resultRequire
    },
  })

  beforeEach(() => {
    ;({ restoreEnv } = overrideEnv({ whitelist: ['APPDATA', 'PATH'] }))
    serverless = new Serverless({ commands: [], options: {} })
    serverless.cli = new CLI()
    serverless.processedInput = { commands: ['print'], options: {} }
    pluginManager = new PluginManager(serverless)
    serviceDir = pluginManager.serverless.serviceDir = 'foo'
  })

  afterEach(() => restoreEnv())

  describe('#constructor()', () => {
    it('should set the serverless instance', () => {
      expect(pluginManager.serverless).to.deep.equal(serverless)
    })

    it('should create an empty cliOptions object', () => {
      expect(pluginManager.cliOptions).to.deep.equal({})
    })

    it('should create an empty cliCommands array', () => {
      expect(pluginManager.cliCommands.length).to.equal(0)
    })

    it('should create an empty plugins array', () => {
      expect(pluginManager.plugins.length).to.equal(0)
    })

    it('should create an empty commands object', () => {
      expect(pluginManager.commands).to.deep.equal({})
    })
  })

  describe('#setCliOptions()', () => {
    it('should set the cliOptions object', () => {
      const options = { foo: 'bar' }
      pluginManager.setCliOptions(options)

      expect(pluginManager.cliOptions).to.deep.equal(options)
    })
  })

  describe('#setCliCommands()', () => {
    it('should set the cliCommands array', () => {
      const commands = ['foo', 'bar']
      pluginManager.setCliCommands(commands)

      expect(pluginManager.cliCommands).to.equal(commands)
    })
  })

  describe('#convertShortcutsIntoOptions()', () => {
    it('should convert shortcuts into options when a one level deep command matches', () => {
      const cliOptionsMock = { r: 'eu-central-1', region: 'us-east-1' }
      const cliCommandsMock = ['deploy'] // command with one level deepness
      const commandMock = {
        options: {
          region: {
            shortcut: 'r',
          },
        },
      }
      pluginManager.setCliCommands(cliCommandsMock)
      pluginManager.setCliOptions(cliOptionsMock)

      pluginManager.convertShortcutsIntoOptions(commandMock)

      expect(pluginManager.cliOptions.region).to.equal(cliOptionsMock.r)
    })

    it('should not convert shortcuts into options when the shortcut is not given', () => {
      const cliOptionsMock = { r: 'eu-central-1', region: 'us-east-1' }
      const cliCommandsMock = ['deploy']
      const commandMock = {
        options: {
          region: {},
        },
      }
      pluginManager.setCliCommands(cliCommandsMock)
      pluginManager.setCliOptions(cliOptionsMock)

      pluginManager.convertShortcutsIntoOptions(commandMock)

      expect(pluginManager.cliOptions.region).to.equal(cliOptionsMock.region)
    })
  })

  describe('#addPlugin()', () => {
    it('should add a plugin instance to the plugins array', () => {
      pluginManager.addPlugin(SynchronousPluginMock)

      expect(pluginManager.plugins[0]).to.be.instanceof(SynchronousPluginMock)
    })

    it('should load two plugins that happen to have the same class name', () => {
      function getFirst() {
        return class PluginMock {}
      }

      function getSecond() {
        return class PluginMock {}
      }

      const first = getFirst()
      const second = getSecond()

      pluginManager.addPlugin(first)
      pluginManager.addPlugin(second)

      expect(pluginManager.plugins[0]).to.be.instanceof(first)
      expect(pluginManager.plugins[1]).to.be.instanceof(second)
      expect(pluginManager.plugins.length).to.equal(2)
    })

    it('should load the plugin commands', () => {
      pluginManager.addPlugin(SynchronousPluginMock)

      expect(pluginManager.commands).to.have.property('deploy')
    })

    it('should skip service related plugins which not match the services provider', () => {
      pluginManager.serverless.service.provider.name = 'someProvider'
      class Plugin {
        constructor() {
          this.provider = 'someOtherProvider'
        }
      }

      pluginManager.addPlugin(Plugin)

      expect(pluginManager.plugins.length).to.equal(0)
    })

    it('should add service related plugins when provider property is the providers name', () => {
      pluginManager.serverless.service.provider.name = 'someProvider'
      class Plugin {
        constructor() {
          this.provider = 'someProvider'
        }
      }

      pluginManager.addPlugin(Plugin)

      expect(pluginManager.plugins[0]).to.be.an.instanceOf(Plugin)
    })

    it('should add service related plugins when provider propery is provider plugin', () => {
      pluginManager.serverless.service.provider.name = 'someProvider'
      class ProviderPlugin {
        static getProviderName() {
          return 'someProvider'
        }
      }
      const providerPlugin = new ProviderPlugin()
      class Plugin {
        constructor() {
          this.provider = providerPlugin
        }
      }

      pluginManager.addPlugin(Plugin)

      expect(pluginManager.plugins[0]).to.be.an.instanceOf(Plugin)
    })
  })

  describe('#asyncPluginInit()', () => {
    it('should call async init on plugins that have it', async () => {
      const plugin1 = new ServicePluginMock1()
      plugin1.asyncInit = sinon.stub().returns(Promise.resolve())
      pluginManager.plugins = [plugin1]
      return pluginManager.asyncPluginInit().then(() => {
        expect(plugin1.asyncInit.calledOnce).to.equal(true)
      })
    })
  })

  describe('#loadAllPlugins()', () => {
    beforeEach(() => {
      mockRequire('ServicePluginMock1', ServicePluginMock1)
      mockRequire('ServicePluginMock2', ServicePluginMock2)
      mockRequire('BrokenPluginMock', BrokenPluginMock)
    })

    it('should load only core plugins when no service plugins are given', async () => {
      // Note: We need the Create plugin for this test to pass
      await pluginManager.loadAllPlugins()

      // note: this test will be refactored as the Create plugin will be moved
      // to another directory
      expect(pluginManager.plugins.length).to.be.above(0)
    })

    it('should load all plugins when service plugins are given', async () => {
      const servicePlugins = ['ServicePluginMock1', 'ServicePluginMock2']
      await pluginManager.loadAllPlugins(servicePlugins)

      expect(
        pluginManager.plugins.some(
          (plugin) => plugin instanceof ServicePluginMock1,
        ),
      ).to.equal(true)
      expect(
        pluginManager.plugins.some(
          (plugin) => plugin instanceof ServicePluginMock2,
        ),
      ).to.equal(true)
      expect(
        pluginManager.plugins.some(
          (plugin) => plugin instanceof EnterprisePluginMock,
        ),
      ).to.equal(true)
      // note: this test will be refactored as the Create plugin will be moved
      // to another directory
      expect(pluginManager.plugins.length).to.be.above(2)
    })

    it('should load all plugins in the correct order', async () => {
      const servicePlugins = ['ServicePluginMock1', 'ServicePluginMock2']

      await pluginManager.loadAllPlugins(servicePlugins)

      const pluginIndexes = [
        pluginManager.plugins.findIndex((plugin) => plugin instanceof Create),
        pluginManager.plugins.findIndex(
          (plugin) => plugin instanceof ServicePluginMock1,
        ),
        pluginManager.plugins.findIndex(
          (plugin) => plugin instanceof ServicePluginMock2,
        ),
        pluginManager.plugins.findIndex(
          (plugin) => plugin instanceof EnterprisePluginMock,
        ),
      ]
      expect(pluginIndexes).to.deep.equal(
        pluginIndexes.slice().sort((a, b) => a - b),
      )
    })

    it('should load the Serverless core plugins', async () => {
      await pluginManager.loadAllPlugins()

      expect(pluginManager.plugins.length).to.be.above(1)
    })

    it('should throw an error when trying to load unknown plugin', () => {
      const servicePlugins = ['ServicePluginMock3', 'ServicePluginMock1']

      return expect(
        pluginManager.loadAllPlugins(servicePlugins),
      ).to.be.rejectedWith(ServerlessError)
    })

    it('should not throw error when trying to load unknown plugin with help flag', async () => {
      const servicePlugins = ['ServicePluginMock3', 'ServicePluginMock1']

      pluginManager.setCliOptions({ help: true })

      resolveInput.clear()
      return overrideArgv({ args: ['serverless', '--help'] }, () => {
        return expect(
          pluginManager.loadAllPlugins(servicePlugins),
        ).to.not.be.rejectedWith(ServerlessError)
      })
    })

    it('should pass through an error when plugin load fails', () => {
      const servicePlugins = ['BrokenPluginMock']

      return expect(
        pluginManager.loadAllPlugins(servicePlugins),
      ).to.be.rejectedWith(brokenPluginError)
    })

    it('should not throw error when running the plugin commands and given plugins does not exist', () => {
      const servicePlugins = ['ServicePluginMock3']
      const cliCommandsMock = ['plugin']
      pluginManager.setCliCommands(cliCommandsMock)

      return expect(
        pluginManager.loadAllPlugins(servicePlugins),
      ).to.not.be.rejectedWith(ServerlessError)
    })

    afterEach(() => {
      mockRequire.stop('ServicePluginMock1')
      mockRequire.stop('ServicePluginMock2')
      mockRequire.stop('BrokenPluginMock')
    })
  })

  describe('#resolveServicePlugins()', () => {
    beforeEach(() => {
      mockRequire('ServicePluginMock1', ServicePluginMock1)
      // Plugins loaded via a relative path should be required relative to the service path
      mockRequire(
        `${serviceDir}/RelativePath/ServicePluginMock2`,
        ServicePluginMock2,
      )
    })

    it('should resolve the service plugins', async () => {
      const servicePlugins = [
        'ServicePluginMock1',
        './RelativePath/ServicePluginMock2',
      ]
      expect(
        await pluginManager.resolveServicePlugins(servicePlugins),
      ).to.deep.equal([ServicePluginMock1, ServicePluginMock2])
    })

    it('should not error if plugins = null', () => {
      // Happens when `plugins` property exists but is empty
      const servicePlugins = null
      return expect(pluginManager.resolveServicePlugins(servicePlugins)).to.not
        .be.rejected
    })

    it('should not error if plugins = undefined', () => {
      // Happens when `plugins` property does not exist
      const servicePlugins = undefined
      return expect(pluginManager.resolveServicePlugins(servicePlugins)).to.not
        .be.rejected
    })

    afterEach(() => {
      mockRequire.stop('ServicePluginMock1')
      mockRequire.stop('ServicePluginMock2')
    })
  })

  describe('#parsePluginsObject()', () => {
    const parsePluginsObjectAndVerifyResult = (
      servicePlugins,
      expectedResult,
    ) => {
      const result = pluginManager.parsePluginsObject(servicePlugins)
      expect(result).to.deep.equal(expectedResult)
    }

    it('should parse array object', () => {
      const servicePlugins = ['ServicePluginMock1', 'ServicePluginMock2']

      parsePluginsObjectAndVerifyResult(servicePlugins, {
        modules: servicePlugins,
        localPath: path.join(serverless.serviceDir, '.serverless_plugins'),
      })
    })

    it('should parse plugins object', () => {
      const servicePlugins = {
        modules: ['ServicePluginMock1', 'ServicePluginMock2'],
        localPath: './myplugins',
      }

      parsePluginsObjectAndVerifyResult(servicePlugins, {
        modules: servicePlugins.modules,
        localPath: servicePlugins.localPath,
      })
    })

    it('should parse plugins object if format is not correct', () => {
      const servicePlugins = {}

      parsePluginsObjectAndVerifyResult(servicePlugins, {
        modules: [],
        localPath: path.join(serverless.serviceDir, '.serverless_plugins'),
      })
    })

    it('should parse plugins object if modules property is not an array', () => {
      const servicePlugins = { modules: {} }

      parsePluginsObjectAndVerifyResult(servicePlugins, {
        modules: [],
        localPath: path.join(serverless.serviceDir, '.serverless_plugins'),
      })
    })

    it('should parse plugins object if localPath is not correct', () => {
      const servicePlugins = {
        modules: ['ServicePluginMock1', 'ServicePluginMock2'],
        localPath: {},
      }

      parsePluginsObjectAndVerifyResult(servicePlugins, {
        modules: servicePlugins.modules,
        localPath: path.join(serverless.serviceDir, '.serverless_plugins'),
      })
    })
  })

  describe('command aliases', () => {
    describe('#getAliasCommandTarget', () => {
      it('should return an alias target', () => {
        pluginManager.aliases = {
          cmd1: {
            cmd2: {
              command: 'command1',
            },
            cmd3: {
              cmd4: {
                command: 'command2',
              },
            },
          },
        }
        expect(pluginManager.getAliasCommandTarget(['cmd1', 'cmd2'])).to.equal(
          'command1',
        )
        expect(
          pluginManager.getAliasCommandTarget(['cmd1', 'cmd3', 'cmd4']),
        ).to.equal('command2')
      })

      it('should return undefined if alias does not exist', () => {
        pluginManager.aliases = {
          cmd1: {
            cmd2: {
              command: 'command1',
            },
            cmd3: {
              cmd4: {
                command: 'command2',
              },
            },
          },
        }
        expect(pluginManager.getAliasCommandTarget(['cmd1'])).to.be.undefined
        expect(pluginManager.getAliasCommandTarget(['cmd1', 'cmd3'])).to.be
          .undefined
      })
    })

    describe('#createCommandAlias', () => {
      it('should create an alias for a command', () => {
        pluginManager.aliases = {}
        expect(
          pluginManager.createCommandAlias('cmd1:alias2', 'cmd2:cmd3:cmd4'),
        ).to.not.throw
        expect(
          pluginManager.createCommandAlias(
            'cmd1:alias2:alias3',
            'cmd2:cmd3:cmd5',
          ),
        ).to.not.throw
        expect(pluginManager.aliases).to.deep.equal({
          cmd1: {
            alias2: {
              command: 'cmd2:cmd3:cmd4',
              alias3: {
                command: 'cmd2:cmd3:cmd5',
              },
            },
          },
        })
      })

      it('should fail if the alias already exists', () => {
        pluginManager.aliases = {
          cmd1: {
            alias2: {
              command: 'cmd2:cmd3:cmd4',
              alias3: {
                command: 'cmd2:cmd3:cmd5',
              },
            },
          },
        }
        expect(() =>
          pluginManager.createCommandAlias('cmd1:alias2', 'mycmd'),
        ).to.throw(/Alias "cmd1:alias2" is already defined/)
      })

      it('should fail if the alias overwrites a command', () => {
        const synchronousPluginMockInstance = new SynchronousPluginMock()
        pluginManager.loadCommands(synchronousPluginMockInstance)
        expect(() =>
          pluginManager.createCommandAlias('deploy', 'mycmd'),
        ).to.throw(/Command "deploy" cannot be overriden/)
      })

      it('should fail if the alias overwrites the very own command', () => {
        const synchronousPluginMockInstance = new SynchronousPluginMock()
        synchronousPluginMockInstance.commands.deploy.commands.onpremises.aliases =
          ['deploy']
        expect(() =>
          pluginManager.loadCommands(synchronousPluginMockInstance),
        ).to.throw(/Command "deploy" cannot be overriden/)
      })
    })
  })

  describe('#loadCommands()', () => {
    it('should load the plugin commands', () => {
      const synchronousPluginMockInstance = new SynchronousPluginMock()
      pluginManager.loadCommands(synchronousPluginMockInstance)

      expect(pluginManager.commands).to.have.property('deploy')
    })

    it('should merge plugin commands', () => {
      pluginManager.loadCommands({
        commands: {
          deploy: {
            lifecycleEvents: ['one'],
            options: {
              foo: {},
            },
          },
        },
      })

      pluginManager.loadCommands({
        commands: {
          deploy: {
            lifecycleEvents: ['one', 'two'],
            options: {
              bar: {},
            },
            commands: {
              fn: {},
            },
          },
        },
      })

      expect(pluginManager.commands.deploy)
        .to.have.property('options')
        .that.has.all.keys('foo', 'bar')
      expect(pluginManager.commands.deploy)
        .to.have.property('lifecycleEvents')
        .that.is.an('array')
        .that.deep.equals(['one', 'two'])
      expect(pluginManager.commands.deploy.commands).to.have.property('fn')
    })

    it('should fail if there is already an alias for a command', () => {
      pluginManager.aliases = {
        deploy: {
          command: 'my:deploy',
        },
      }

      const synchronousPluginMockInstance = new SynchronousPluginMock()
      expect(() =>
        pluginManager.loadCommands(synchronousPluginMockInstance),
      ).to.throw(/Command "deploy" cannot override an existing alias/)
    })
  })

  describe('#loadHooks()', () => {
    let deprecatedPluginInstance

    beforeEach(() => {
      deprecatedPluginInstance = new DeprecatedLifecycleEventsPluginMock()
      pluginManager.deprecatedEvents = {
        'deprecated:deprecated': 'new:new',
      }
    })

    afterEach(() => {
      pluginManager.deprecatedEvents = {}
    })

    it('should replace deprecated events with the new ones', () => {
      pluginManager.loadHooks(deprecatedPluginInstance)

      expect(pluginManager.hooks['deprecated:deprecated']).to.equal(undefined)
      expect(pluginManager.hooks['new:new'][0].pluginName).to.equal(
        'DeprecatedLifecycleEventsPluginMock',
      )
      expect(pluginManager.hooks['untouched:untouched'][0].pluginName).to.equal(
        'DeprecatedLifecycleEventsPluginMock',
      )
    })
  })

  describe('#getPlugins()', () => {
    beforeEach(() => {
      mockRequire('ServicePluginMock1', ServicePluginMock1)
      mockRequire('ServicePluginMock2', ServicePluginMock2)
    })

    it('should return all loaded plugins', async () => {
      const servicePlugins = ['ServicePluginMock1', 'ServicePluginMock2']
      await pluginManager.loadAllPlugins(servicePlugins)

      const plugins = pluginManager.getPlugins()
      expect(plugins.length).to.be.above(3)
      expect(plugins.some((plugin) => plugin instanceof ServicePluginMock1)).to
        .be.true
      expect(plugins.some((plugin) => plugin instanceof ServicePluginMock2)).to
        .be.true
    })

    afterEach(() => {
      mockRequire.stop('ServicePluginMock1')
      mockRequire.stop('ServicePluginMock2')
    })
  })

  describe('#validateCommand()', () => {
    it('should find commands', () => {
      pluginManager.addPlugin(EntrypointPluginMock)

      expect(() =>
        pluginManager.validateCommand(['mycmd', 'mysubcmd']),
      ).to.not.throw(ServerlessError)
    })

    it('should find container children commands', () => {
      pluginManager.addPlugin(ContainerPluginMock)

      expect(() =>
        pluginManager.validateCommand(['mycontainer', 'mysubcmd']),
      ).to.not.throw(ServerlessError)
    })
  })

  describe('#assignDefaultOptions()', () => {
    it('should assign default values to empty options', () => {
      pluginManager.commands = {
        foo: {
          options: {
            bar: {
              required: true,
              default: 'foo',
            },
          },
        },
      }

      const foo = pluginManager.commands.foo
      pluginManager.assignDefaultOptions(foo)

      expect(pluginManager.cliOptions.bar).to.equal(foo.options.bar.default)
    })

    it('should not assign default values to non-empty options', () => {
      pluginManager.commands = {
        foo: {
          options: {
            bar: {
              required: true,
              default: 'foo',
            },
          },
        },
      }

      const foo = pluginManager.commands.foo
      pluginManager.setCliOptions({ bar: 100 })
      pluginManager.assignDefaultOptions(foo)

      expect(pluginManager.cliOptions.bar).to.equal(100)
    })
    ;[0, '', false].forEach((defaultValue) => {
      it(`assigns valid falsy default value '${defaultValue} to empty options`, () => {
        pluginManager.commands = {
          foo: {
            options: {
              bar: {
                required: true,
                default: defaultValue,
              },
            },
          },
        }

        const foo = pluginManager.commands.foo
        pluginManager.assignDefaultOptions(foo)

        expect(pluginManager.cliOptions.bar).to.equal(defaultValue)
      })
    })
  })

  describe('#validateServerlessConfigDependency()', () => {
    let serverlessInstance
    let pluginManagerInstance

    beforeEach(() => {
      serverlessInstance = new Serverless({ commands: [], options: {} })
      serverlessInstance.configurationInput = null
      serverlessInstance.serviceDir = 'my-service'
      pluginManagerInstance = new PluginManager(serverlessInstance)
    })

    it('should continue loading if the configDependent property is absent', () => {
      pluginManagerInstance.commands = {
        foo: {},
      }

      const foo = pluginManagerInstance.commands.foo

      expect(pluginManagerInstance.validateServerlessConfigDependency(foo)).to
        .be.undefined
    })

    it('should load if the configDependent property is false and config is null', () => {
      pluginManagerInstance.commands = {
        foo: {
          configDependent: false,
        },
      }

      const foo = pluginManagerInstance.commands.foo

      expect(pluginManagerInstance.validateServerlessConfigDependency(foo)).to
        .be.undefined
    })

    it('should throw an error if configDependent is true and no config is found', () => {
      pluginManagerInstance.commands = {
        foo: {
          configDependent: true,
        },
      }

      const foo = pluginManagerInstance.commands.foo

      expect(() => {
        pluginManager.validateServerlessConfigDependency(foo)
      }).to.throw(Error)
    })

    it('should throw an error if configDependent is true and config is an empty string', () => {
      pluginManagerInstance.commands = {
        foo: {
          configDependent: true,
        },
      }

      const foo = pluginManagerInstance.commands.foo

      expect(() => {
        pluginManager.validateServerlessConfigDependency(foo)
      }).to.throw(Error)
    })

    it('should load if the configDependent property is true and config exists', () => {
      pluginManagerInstance.serverless.configurationInput = {
        servicePath: 'foo',
      }

      pluginManagerInstance.commands = {
        foo: {
          configDependent: true,
        },
      }

      const foo = pluginManagerInstance.commands.foo

      expect(pluginManagerInstance.validateServerlessConfigDependency(foo)).to
        .be.undefined
    })
  })

  describe('#run()', () => {
    it('should throw an error when the given command is not available', () => {
      pluginManager.addPlugin(SynchronousPluginMock)

      const commandsArray = ['foo']

      return expect(pluginManager.run(commandsArray)).to.be.rejectedWith(Error)
    })

    it('should throw an error when the given command is an entrypoint', () => {
      pluginManager.addPlugin(EntrypointPluginMock)

      const commandsArray = ['myep']

      return expect(pluginManager.run(commandsArray)).to.be.rejectedWith(Error)
    })

    it('should NOT throw an error when the given command is a child of a container', () => {
      pluginManager.addPlugin(ContainerPluginMock)

      const commandsArray = ['mycontainer', 'mysubcmd']

      return expect(pluginManager.run(commandsArray)).to.not.be.rejectedWith(
        Error,
      )
    })

    it('should throw an error when the given command is a child of an entrypoint', () => {
      pluginManager.addPlugin(EntrypointPluginMock)

      const commandsArray = ['mysubcmd']

      return expect(pluginManager.run(commandsArray)).to.be.rejectedWith(Error)
    })

    it('should run the hooks in the correct order', async () => {
      class CorrectHookOrderPluginMock {
        constructor() {
          this.commands = {
            run: {
              usage: 'Pushes the current hook status on the hookStatus array',
              lifecycleEvents: [
                'beforeHookStatus',
                'midHookStatus',
                'afterHookStatus',
              ],
            },
          }

          this.hooks = {
            initialize: async () => this.initializeHookStatus(),
            'before:run:beforeHookStatus': async () => this.beforeHookStatus(),
            'run:midHookStatus': async () => this.midHookStatus(),
            'after:run:afterHookStatus': async () => this.afterHookStatus(),
          }

          // used to test if the hooks were run in the correct order
          this.hookStatus = []
        }

        initializeHookStatus() {
          this.hookStatus.push('initialize')
        }

        beforeHookStatus() {
          this.hookStatus.push('before')
        }

        midHookStatus() {
          this.hookStatus.push('mid')
        }

        afterHookStatus() {
          this.hookStatus.push('after')
        }
      }

      pluginManager.addPlugin(CorrectHookOrderPluginMock)
      const commandsArray = ['run']
      return pluginManager.run(commandsArray).then(() => {
        expect(pluginManager.plugins[0].hookStatus[0]).to.equal('initialize')
        expect(pluginManager.plugins[0].hookStatus[1]).to.equal('before')
        expect(pluginManager.plugins[0].hookStatus[2]).to.equal('mid')
        expect(pluginManager.plugins[0].hookStatus[3]).to.equal('after')
      })
    })

    describe('when using a synchronous hook function', () => {
      beforeEach(() => {
        pluginManager.addPlugin(SynchronousPluginMock)
      })

      describe('when running a simple command', () => {
        it('should run a simple command', async () => {
          const commandsArray = ['deploy']
          return pluginManager
            .run(commandsArray)
            .then(() =>
              expect(pluginManager.plugins[0].deployedFunctions).to.equal(1),
            )
        })
      })

      describe('when running a nested command', () => {
        it('should run the nested command', async () => {
          const commandsArray = ['deploy', 'onpremises']
          return pluginManager
            .run(commandsArray)
            .then(() =>
              expect(pluginManager.plugins[0].deployedResources).to.equal(1),
            )
        })
      })
    })

    describe('when using a promise based hook function', () => {
      beforeEach(() => {
        pluginManager.addPlugin(PromisePluginMock)
      })

      describe('when running a simple command', () => {
        it('should run the simple command', async () => {
          const commandsArray = ['deploy']
          return pluginManager
            .run(commandsArray)
            .then(() =>
              expect(pluginManager.plugins[0].deployedFunctions).to.equal(1),
            )
        })
      })

      describe('when running a nested command', () => {
        it('should run the nested command', async () => {
          const commandsArray = ['deploy', 'onpremises']
          return pluginManager
            .run(commandsArray)
            .then(() =>
              expect(pluginManager.plugins[0].deployedResources).to.equal(1),
            )
        })
      })
    })

    describe('when using provider specific plugins', () => {
      beforeEach(() => {
        pluginManager.serverless.service.provider.name = 'provider1'

        pluginManager.addPlugin(Provider1PluginMock)
        pluginManager.addPlugin(Provider2PluginMock)

        // this plugin should be run each and every time as it doesn't specify any provider
        pluginManager.addPlugin(SynchronousPluginMock)
      })

      it('should load only the providers plugins (if the provider is specified)', async () => {
        const commandsArray = ['deploy']
        return pluginManager.run(commandsArray).then(() => {
          expect(pluginManager.plugins.length).to.equal(2)
          expect(pluginManager.plugins[0].deployedFunctions).to.equal(1)
          expect(pluginManager.plugins[0].provider).to.equal('provider1')
          expect(pluginManager.plugins[1].deployedFunctions).to.equal(1)
          expect(pluginManager.plugins[1].provider).to.equal(undefined)
        })
      })
    })

    it('should run commands with internal lifecycles', async () => {
      pluginManager.addPlugin(EntrypointPluginMock)

      const commandsArray = ['mycmd', 'spawncmd']

      return pluginManager.run(commandsArray).then(() => {
        expect(pluginManager.plugins[0].callResult).to.equal(
          '>subInitialize>subFinalize>initialize>finalize>run>subEPInitialize>subEPFinalize',
        )
      })
    })
  })

  describe('#getCommands()', () => {
    it('should hide entrypoints on any level and only return commands', () => {
      pluginManager.addPlugin(EntrypointPluginMock)

      const commands = pluginManager.getCommands()
      expect(commands).to.have.a.property('mycmd')
      expect(commands).to.have.a.nested.property('mycmd.commands.mysubcmd')
      expect(commands).to.have.a.nested.property('mycmd.commands.spawncmd')
      // Check for omitted entrypoints
      expect(commands).to.not.have.a.property('myep')
      expect(commands).to.not.have.a.nested.property('myep.commands.mysubep')
      expect(commands).to.not.have.a.nested.property('mycmd.commands.spawnep')
    })

    it('should return aliases', () => {
      pluginManager.addPlugin(AliasPluginMock)

      const commands = pluginManager.getCommands()
      expect(commands)
        .to.have.a.property('on')
        .that.has.a.nested.property('commands.premise')
      expect(commands).to.have.a.property('premise')
    })
  })

  describe('#getCommand()', () => {
    beforeEach(() => {
      pluginManager.addPlugin(SynchronousPluginMock)
      pluginManager.serverless.cli.loadedCommands = {
        create: {
          usage: 'Create new Serverless service',
          lifecycleEvents: ['create'],
          options: {
            template: {
              usage:
                'Template for the service. Available templates: ", "aws-nodejs", "..."',
              shortcut: 't',
            },
          },
          key: 'create',
          pluginName: 'Create',
        },
        deploy: {
          usage: 'Deploy a Serverless service',
          configDependent: true,
          lifecycleEvents: ['cleanup', 'initialize'],
          options: {
            conceal: {
              usage:
                'Hide secrets from the output (e.g. API Gateway key values)',
            },
            stage: {
              usage: 'Stage of the service',
              shortcut: 's',
            },
          },
          key: 'deploy',
          pluginName: 'Deploy',
          commands: {
            function: {
              usage: 'Deploy a single function from the service',
              lifecycleEvents: ['initialize', 'packageFunction', 'deploy'],
              options: {
                function: {
                  usage: 'Name of the function',
                  shortcut: 'f',
                  required: true,
                },
              },
              key: 'deploy:function',
              pluginName: 'Deploy',
            },
            list: {
              usage: 'List deployed version of your Serverless Service',
              lifecycleEvents: ['log'],
              key: 'deploy:list',
              pluginName: 'Deploy',
              commands: {
                functions: {
                  usage: 'List all the deployed functions and their versions',
                  lifecycleEvents: ['log'],
                  key: 'deploy:list:functions',
                  pluginName: 'Deploy',
                },
              },
            },
          },
        },
      }
    })
  })

  describe('#spawn()', () => {
    it('should throw an error when the given command is not available', async () => {
      pluginManager.addPlugin(EntrypointPluginMock)

      const commandsArray = ['foo']

      return expect(
        pluginManager.spawn(commandsArray),
      ).to.eventually.be.rejectedWith(Error)
    })

    describe('when invoking a command', () => {
      it('should succeed', async () => {
        pluginManager.addPlugin(EntrypointPluginMock)

        const commandsArray = ['mycmd']

        return pluginManager.spawn(commandsArray).then(() => {
          expect(pluginManager.plugins[0].callResult).to.equal('>run')
        })
      })

      it('should spawn nested commands', async () => {
        pluginManager.addPlugin(EntrypointPluginMock)

        const commandsArray = ['mycmd', 'mysubcmd']

        return pluginManager.spawn(commandsArray).then(() => {
          expect(pluginManager.plugins[0].callResult).to.equal(
            '>subInitialize>subFinalize',
          )
        })
      })

      it('should terminate the hook chain if requested', async () => {
        pluginManager.addPlugin(EntrypointPluginMock)

        const commandsArray = ['mycmd', 'mysubcmd']

        return expect(
          pluginManager.spawn(commandsArray, {
            terminateLifecycleAfterExecution: true,
          }),
        )
          .to.be.rejectedWith('Terminating mycmd:mysubcmd')
          .then(() => {
            expect(pluginManager.plugins[0].callResult).to.equal(
              '>subInitialize>subFinalize',
            )
          })
      })
    })

    describe('when invoking a container', () => {
      it('should spawn nested commands', async () => {
        pluginManager.addPlugin(ContainerPluginMock)

        const commandsArray = ['mycontainer', 'mysubcmd']

        return pluginManager.spawn(commandsArray).then(() => {
          expect(pluginManager.plugins[0].callResult).to.equal(
            '>mysubcmdEvent1>mysubcmdEvent2',
          )
        })
      })
    })

    describe('when invoking an entrypoint', () => {
      it('should succeed', async () => {
        pluginManager.addPlugin(EntrypointPluginMock)

        const commandsArray = ['myep']

        return pluginManager.spawn(commandsArray).then(() => {
          expect(pluginManager.plugins[0].callResult).to.equal(
            '>initialize>finalize',
          )
        })
      })

      it('should spawn nested entrypoints', async () => {
        pluginManager.addPlugin(EntrypointPluginMock)

        const commandsArray = ['myep', 'mysubep']

        return pluginManager.spawn(commandsArray).then(() => {
          expect(pluginManager.plugins[0].callResult).to.equal(
            '>subEPInitialize>subEPFinalize',
          )
        })
      })

      describe('with string formatted syntax', () => {
        it('should succeed', async () => {
          pluginManager.addPlugin(EntrypointPluginMock)

          return pluginManager.spawn('myep').then(() => {
            expect(pluginManager.plugins[0].callResult).to.equal(
              '>initialize>finalize',
            )
          })
        })

        it('should spawn nested entrypoints', async () => {
          pluginManager.addPlugin(EntrypointPluginMock)

          return pluginManager.spawn('myep:mysubep').then(() => {
            expect(pluginManager.plugins[0].callResult).to.equal(
              '>subEPInitialize>subEPFinalize',
            )
          })
        })
      })
    })

    it('should spawn entrypoints with internal lifecycles', async () => {
      pluginManager.addPlugin(EntrypointPluginMock)

      const commandsArray = ['myep', 'spawnep']

      return pluginManager.spawn(commandsArray).then(() => {
        expect(pluginManager.plugins[0].callResult).to.equal(
          '>subInitialize>subFinalize>initialize>finalize>run>subEPInitialize>subEPFinalize',
        )
      })
    })
  })

  describe('Plugin / Load local plugins', () => {
    const cwd = process.cwd()
    let tmpDir
    beforeEach(() => {
      tmpDir = getTmpDirPath()
      serviceDir = path.join(tmpDir, 'service')
      fse.mkdirsSync(serviceDir)
      process.chdir(serviceDir)
      pluginManager.serverless.serviceDir = serviceDir
    })

    it('should load plugins from .serverless_plugins', async () => {
      const localPluginDir = path.join(
        serviceDir,
        '.serverless_plugins',
        'local-plugin',
      )
      installPlugin(localPluginDir, SynchronousPluginMock)

      await pluginManager.loadAllPlugins(['local-plugin'])
      expect(pluginManager.plugins).to.satisfy((plugins) =>
        plugins.some(
          (plugin) => plugin.constructor.name === 'SynchronousPluginMock',
        ),
      )
    })

    it('should load plugins from custom folder', async () => {
      const localPluginDir = path.join(
        serviceDir,
        'serverless-plugins-custom',
        'local-plugin',
      )
      installPlugin(localPluginDir, SynchronousPluginMock)

      await pluginManager.loadAllPlugins({
        localPath: path.join(serviceDir, 'serverless-plugins-custom'),
        modules: ['local-plugin'],
      })
      // Had to use constructor.name because the class will be loaded via
      // require and the reference will not match with SynchronousPluginMock
      expect(pluginManager.plugins).to.satisfy((plugins) =>
        plugins.some(
          (plugin) => plugin.constructor.name === 'SynchronousPluginMock',
        ),
      )
    })

    it('should load plugins from custom folder outside of serviceDir', async () => {
      serviceDir = path.join(tmpDir, 'serverless-plugins-custom')
      const localPluginDir = path.join(serviceDir, 'local-plugin')
      installPlugin(localPluginDir, SynchronousPluginMock)

      await pluginManager.loadAllPlugins({
        localPath: serviceDir,
        modules: ['local-plugin'],
      })
      // Had to use constructor.name because the class will be loaded via
      // require and the reference will not match with SynchronousPluginMock
      expect(pluginManager.plugins).to.satisfy((plugins) =>
        plugins.some(
          (plugin) => plugin.constructor.name === 'SynchronousPluginMock',
        ),
      )
    })

    afterEach(() => {
      process.chdir(cwd)
      try {
        fse.removeSync(tmpDir)
      } catch (e) {
        // Couldn't delete temporary file
      }
    })
  })
})

describe('test/unit/lib/classes/PluginManager.test.js', () => {
  it('should load plugins relatively to the working directory', async () => {
    const { servicePath: serviceDir } = await fixtures.setup('aws')
    const localPluginDir = path.join(serviceDir, 'node_modules', 'local-plugin')
    const parentPluginDir = path.join(
      serviceDir,
      '..',
      'node_modules',
      'parent-plugin',
    )
    installPlugin(localPluginDir, SynchronousPluginMock)
    installPlugin(parentPluginDir, PromisePluginMock)
    await fsp.appendFile(
      path.join(serviceDir, 'serverless.yml'),
      'plugins:\n  - local-plugin\n  - parent-plugin',
    )

    const { serverless } = await runServerless({
      cwd: serviceDir,
      command: 'print',
    })

    const pluginNames = new Set(
      serverless.pluginManager.plugins.map((plugin) => plugin.constructor.name),
    )
    expect(pluginNames).to.contain('SynchronousPluginMock')
    expect(pluginNames).to.contain('PromisePluginMock')
  })

  it('should pass log writers to external plugins', async () => {
    const { serverless } = await runServerless({
      fixture: 'plugin',
      command: 'print',
    })
    const plugin = Array.from(serverless.pluginManager.externalPlugins).find(
      (externalPlugin) => externalPlugin.constructor.name === 'TestPlugin',
    )
    expect(typeof plugin.utils.log).to.equal('function')
    expect(typeof plugin.utils.progress.create).to.equal('function')
    expect(typeof plugin.utils.writeText).to.equal('function')
  })

  it('should error out for duplicate plugin definiton', async () => {
    await expect(
      runServerless({
        fixture: 'plugin',
        command: 'print',
        configExt: {
          plugins: ['./plugin', './plugin'],
        },
      }),
    ).to.be.eventually.rejected.and.have.property(
      'code',
      'DUPLICATE_PLUGIN_DEFINITION',
    )
  })

  it('should pass through an error when trying to load a plugin with error', async () => {
    await expect(
      runServerless({
        fixture: 'plugin',
        command: 'print',
        configExt: {
          plugins: ['./broken-plugin'],
        },
      }),
    ).to.be.eventually.rejectedWith(Error, 'failed to load plugin')
  })

  it('should load ESM plugins', async () => {
    const { serverless } = await runServerless({
      fixture: 'plugin',
      command: 'print',
      configExt: {
        plugins: ['./local-esm-plugin', 'esm-plugin'],
      },
    })

    const pluginNames = new Set(
      serverless.pluginManager.plugins.map((plugin) => plugin.constructor.name),
    )
    expect(pluginNames).to.include('LocalESMPlugin')
    expect(pluginNames).to.include('ESMPlugin')
  })
})
